feat: support multi-utterance persona messages - #71
Conversation
The engine can split a single persona turn into multiple utterances, each tagged with an utterance_id. Message.content stays the full concatenated turn text for backward compatibility; Message.utterances now exposes an ordered, per-utterance breakdown so consumers can render each utterance as its own chat bubble. MessageStreamEvent also carries utterance_id per chunk. Both fields are omitted/None for engines that don't send utterance ids, and utterances are persona-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
There was a problem hiding this comment.
Pull request overview
Adds support for multi-utterance persona turns by exposing per-chunk utterance_id on stream events and a merged per-utterance breakdown on built persona Message objects, while keeping Message.content as the verbatim concatenation for backward compatibility.
Changes:
- Introduces
MessageUtteranceand addsMessage.utterances: list[MessageUtterance] | Nonefor persona messages when utterance IDs are present. - Extends
MessageStreamEventwithutterance_id: str | Noneand threads it through the client event pipeline. - Adds tests covering utterance-id passthrough, missing/empty collapse to
None, utterance merging behavior, and unchanged shape when no utterance IDs are provided.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
tests/test_client.py |
Adds tests validating utterance_id passthrough and Message.utterances construction/compat behavior. |
src/anam/types.py |
Adds MessageUtterance, extends Message and MessageStreamEvent to support utterance breakdown. |
src/anam/client.py |
Parses utterance_id, emits it on stream events, and builds/updates per-message utterance breakdown. |
src/anam/__init__.py |
Exports MessageUtterance as part of the public package surface. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Mirrors _extract_correlation_id's type check so a non-string truthy value from the backend can't violate MessageStreamEvent's str | None contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
ziollek
left a comment
There was a problem hiding this comment.
Dual-model review (Claude + Codex) — multi-utterance parity
Reviewed alongside javascript-sdk #228. Approving, with findings to address for true cross-SDK parity. These are new points, not a re-raise of the already-resolved lstrip thread.
Important — Python and JS now produce different utterances for identical engine input
The earlier lstrip comment was closed with the rationale that it "mirrors the JS SDK's appendUtterance, which calls content.trimStart() unconditionally." That rationale is now stale: JS #228 commit a1b80df ("preserve utterance leading whitespace") replaced trimStart() with single-separator stripping. Proven by running both implementations on the same input the JS harness uses — chunk1 ' Leading'/uuid-a, chunk2 ' Second'/uuid-b:
| first utterance | second utterance | |
|---|---|---|
| JS (correct) | ' Leading' (verbatim) |
' Second' (one separator space removed) |
Python (lstrip) |
'Leading' (leading space lost) |
'Second' (all leading whitespace lost) |
Since the two SDKs are meant to be parity implementations, they should not diverge here. JS is the agreed behavior. Suggested fix in _append_utterance (src/anam/client.py:392):
content = event.content
if previous and content.startswith(" "):
content = content[1:]
return previous + [MessageUtterance(id=event.utterance_id, content=content)]Important — test masks the divergence
test_history_splits_utterances_keeps_turn_shape (tests/test_client.py) picks inputs where lstrip() and single-space-strip coincide (first utterance has no leading space; second has exactly one), so it green-lights the divergent behavior. When fixing the above, add the two cases that actually differ — a first utterance with a genuine leading space (preserved) and a later utterance with multiple leading spaces (exactly one removed) — mirroring JS's testHistoryPreservesNonSeparatorWhitespace.
Minor — per-chunk full-list rebuild (perf, both SDKs)
src/anam/client.py:391-392 rebuilds the whole utterances list on every chunk (previous[:-1] + [merged_last] / previous + [...]) → O(U·C) per turn. Negligible at realistic sizes and immutability is the existing pattern, so low priority; noted for parity with the same pattern in JS.
Verified safe / not reported: no concurrency or re-entrancy issue (_process_message_stream_event is synchronous with no await between read and write of _message_history); the role guard on _append_utterance is correct (it is the actual persona gate — not redundant); mixed-tag / mid-turn-drop behavior matches JS and was already discussed.
Only the single joining space prepended before a subsequent utterance is stripped; the first utterance in a turn, and any other leading whitespace, is now preserved verbatim. Mirrors the JS SDK's a1b80df. Also adds a regression test confirming a previously published Message snapshot isn't mutated by later chunks for the same turn (the Python port already builds fresh Message/utterance objects on every update, so it never had the mutation bug the JS SDK introduced and fixed in its own perf pass). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
Summary
This is a purely additive change — the shape and behavior of the existing
Message.content/MessageStreamEvent.contentfields are unchanged. It adds a new, optional path for consumers who want to build a message as a set of utterances instead of one block of text.utterance_id. This adds support for exposing that breakdown so consumers can render each utterance as its own chat bubble instead of only ever seeing the whole turn as one block of text.MessageStreamEventgains anutterance_id: str | Nonefield, populated per chunk when Anam sends one (empty/missing collapses toNone, matching existing behavior for older responses).Messagegains anutterances: list[MessageUtterance] | Nonefield — an ordered, per-utterance breakdown of the turn, built by merging consecutive chunks that share anutterance_id.Message.contentis untouched by this and still holds the full concatenated turn text exactly as before.MessageUtteranceis a new small type:{ id: str, content: str }.utterancesstaysNonewhenever Anam doesn't send utterance ids, so existing integrations that only readcontentsee no change at all. It's also only ever populated on persona messages (never on user messages).Test plan
pytest- full suite passes, including new tests covering: utterance id exposed on stream events, missing/empty utterance id collapsing toNone, a turn splitting into multiple utterances whilecontentstays a verbatim concatenation, and message shape staying unchanged when no utterance ids are sent.ruff check- clean.mypy- no new errors introduced.🤖 Generated with Claude Code
Summary by cubic
Supports multi-utterance persona turns so clients can render each utterance as its own bubble. Previously persona turns were one concatenated message; now stream chunks include an utterance_id and the built Message exposes a per-utterance breakdown while keeping content unchanged.
Written for commit dee8885. Summary will update on new commits.